import axios, { AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse } from 'axios';

import User from '../../user/user';
import HttpMethod from '../constants/http-method-constant';
import HttpStatus from '../constants/http-status-code-constant';
import ErrorDetails from '../models/error-details';
import HttpResponseResult from '../models/http-response-result';
import LocalGeneralConfig from '../models/local-general-config';
import StringKeyObject from '../models/string-key-object';
import Token from '../models/token';
import useAuthenticationStore from '../stores/authentication-store';
import AuthenticationService from './authentication-service';
import CommonService from './common-service';
import ConfigService from './config-service';
import HttpResponseCodeManager from './http-response-code-manager';
import MessageService from './message-service';
import TokenService from './token-service';

export enum RequestAction {
  Normal = 1,
  Download,
  Upload
}

export default class HttpRequestService {
  private static localGeneralConfig: LocalGeneralConfig;
  private static refreshingTokenPromise: Promise<void> | null;

  /**
   * Combines the provided base url and relative url. 
   * Does NOT use HttpInterceptor to achieve this goal because we want to 
   * make the code logic clear and in one place.
   * @param baseUrl The base url for current http request.
   * @param url The relative url string.
   * @returns The full url. 
   */
  static combineUrl(baseUrl: string, url: string): string {
    if (baseUrl && baseUrl !== '') {
      // Converts the input url to lower case just in case.
      // url = url.toLowerCase();

      // Checks if the input url already has a leading slash.
      if (!url.startsWith('/')) {
        // Appends the leading slash if needed. 
        url = '/' + url;
      }

      // Checks if the input url already starts with the base url.
      if (url.startsWith(baseUrl)) {
        // If already contains the base url.
        return url;
      } else {
        // Appends the base url if needed.
        return baseUrl + url;
      }
    } else {
      return url;
    }
  }

  /**
   * Combines the relative url with default base url. 
   * @param url The relative url string.
   * @returns The full url. 
   */
  static async combineUrlWithDefault(url: string): Promise<string> {
    await this.loadLocalGeneralConfig();
    return this.combineUrl(this.localGeneralConfig.httpBaseUrl, url);
  }

  /**
   * Makes the original http request based on [httpMethod] which does not attach any default header and parameter.
   * This is suitable for loading local json file or making a request to another remote system url.
   * @param httpMethod The string of the http method. Use HttpMethodConstant as the parameter.
   * @param url The string of current requesting url.
   * @param data The params for http get and body for http post.
   * @returns The HttpResponseResult instance.
   */
  static async makeOriginalRequest(httpMethod: string, url: string, data: StringKeyObject = {}): Promise<HttpResponseResult> {
    // Making original request usually used to load json config file.
    // That's why we cannot read config from json file here.
    // Otherwise recursive error happens.
    const config = {
      timeout: CommonService.isDevEnv ? 99999 : 2000,
    };

    let response: AxiosResponse;

    try {
      if (httpMethod === HttpMethod.get) {
        response = await axios.get(url, { ...config, params: data });
      } else if (httpMethod === HttpMethod.post) {
        response = await axios.post(url, data, config);
      } else if (httpMethod === HttpMethod.put) {
        response = await axios.put(url, data, config);
      } else if (httpMethod === HttpMethod.patch) {
        response = await axios.patch(url, data, config);
      } else if (httpMethod === HttpMethod.delete) {
        response = await axios.delete(url, {
          data: data,
          ...config,
        });
      }
    } catch (e: any) {
      response = e.response;
    }

    return {
      success: true,
      payload: response!.data,
    };
  }

  /**
   * Gets the remote server data.
   * @param url The remote server relative url. The base url will be appended automatically.
   * @param params The parameters for getting data.
   * @returns The HttpResponseResult instance.
   */
  static async get(url: string, params: StringKeyObject = {}, requestAction: RequestAction = RequestAction.Normal): Promise<HttpResponseResult> {
    await this.loadLocalGeneralConfig();
    return this.makeRequestWithTokenCheck(HttpMethod.get, this.localGeneralConfig.httpBaseUrl, url, params, requestAction);
  }

  /**
   * Posts the data to the remote server.
   * @param url The remote server relative url. The base url will be appended automatically.
   * @param body The payload body data for post request.
   * @returns The HttpResponseResult instance.
   */
  static async post(url: string, body: StringKeyObject = {}, requestAction: RequestAction = RequestAction.Normal): Promise<HttpResponseResult> {
    await this.loadLocalGeneralConfig();
    return this.makeRequestWithTokenCheck(HttpMethod.post, this.localGeneralConfig.httpBaseUrl, url, body, requestAction);
  }

  /**
   * Gets common AxiosRequestConfig.
   */
  private static getAxiosRequestConfig(): AxiosRequestConfig {
    const configuredTimeout = this.localGeneralConfig.httpRequestTimeoutInSecond * 1000;

    // https://vitejs.dev/guide/env-and-mode.html
    // Defines when http request should raise timeout exception.
    // The short timeout duration works in release mode only.
    return {
      timeout: CommonService.isDevEnv ? 99999 : configuredTimeout,
    };
  }

  /**
   * Loads base url from config if needed.
   */
  private static async loadLocalGeneralConfig(): Promise<void> {
    this.localGeneralConfig = await ConfigService.getLocalGeneralConfig();
  }

  /**
   * Creates the authroization http request header.
   */
  private static createAuthorizationRequestHeader(): AxiosRequestHeaders {
    const header: any = {};

    if (TokenService.hasAccessToken) {
      header['authorization'] = `${TokenService.bearer} ${TokenService.activeAccessToken}`;
    }

    return header as AxiosRequestHeaders;
  }

  /**
   * Creates the customized http request header.
   */
  private static createRequestHeader(): AxiosRequestHeaders {
    const header = this.createAuthorizationRequestHeader();
    header['content-type'] = 'application/json';

    return header as AxiosRequestHeaders;
  }

  /**
  * Check if need to refresh the token based on the current requesting url.
  * @param url The string of current requesting url.
  * @returns True means need to refresh the token. False means NO such need.
  */
  private static async needToRefreshToken(url: string): Promise<boolean> {
    return (this.localGeneralConfig.noTokenRefreshNeedUrls).filter((p) => url.endsWith(p)).length === 0
  }

  /**
   * Makes the http request.
   * @param httpMethod The string of the http method. Use HttpMethodConstant as the parameter.
   * @param baseUrl The string of http request base url.
   * @param url The string of current requesting url.
   * @param data The params for http get and body for http post.
   * @returns The HttpResponseResult instance.
   */
  private static async makeRequest(httpMethod: string, baseUrl: string, url: string, data: StringKeyObject = {}, requestAction: RequestAction = RequestAction.Normal): Promise<AxiosResponse | null> {
    let response: AxiosResponse | null = null;
    const header = this.createRequestHeader();

    if (requestAction === RequestAction.Upload) {
      header['content-type'] = 'multipart/form-data';
    }

    const config: AxiosRequestConfig = {
      baseURL: baseUrl,
      headers: header,
      ...this.getAxiosRequestConfig(),
    };

    if (requestAction === RequestAction.Download) {
      config.responseType = 'blob';
    }

    try {
      // Uploads file proess.
      if (requestAction !== RequestAction.Upload) {
        if (httpMethod === HttpMethod.get) {
          response = await axios.get(url, { ...config, params: data });
        } else if (httpMethod === HttpMethod.post) {
          response = await axios.post(url, data, config);
        } else if (httpMethod === HttpMethod.put) {
          response = await axios.put(url, data, config);
        } else if (httpMethod === HttpMethod.patch) {
          response = await axios.patch(url, data, config);
        } else if (httpMethod === HttpMethod.delete) {
          response = await axios.delete(url, {
            data: data,
            ...config,
          });
        }
      } else {
        const formData = new FormData();

        // For upload purpose we need to put file/file array in data parameter as well as other necessary parameters.
        // That's why we check on array type and we take key as upload parameter name which will be referenced by backend api.
        for (const key in data) {
          if (Array.isArray(data[key])) {
            for (const item of data[key]) {
              formData.append(key, item);
            }
          } else {
            formData.append(key, data[key]);
          }
        }

        response = await axios.post(url, formData, config);
      }
    } catch (e: any) {
      response = e.response;
    }

    // Downloads file process.
    if (requestAction === RequestAction.Download) {
      // Response example: 
      // content-disposition: "attachment; filename=book.csv"
      const filename = response!.headers['content-disposition'].substring(response!.headers['content-disposition'].indexOf('=') + 1);

      const href = window.URL.createObjectURL(response!.data);
      const anchorElement = document.createElement('a');
      anchorElement.href = href;
      anchorElement.download = filename;
      document.body.appendChild(anchorElement);
      anchorElement.click();
      document.body.removeChild(anchorElement);
      window.URL.revokeObjectURL(href);
    }

    return response;
  }

  /**
   * Processes the http request by attaching it with other operators. This method will check the local token.
   * If the local access token expired the refresh token request will be raised before the user request.
   * Once the new access token is retrieved the user request will contiune.
   * @param httpMethod The string of the http method. Use HttpMethodConstant as the parameter.
   * @param baseUrl The string of http request base url.
   * @param url The string of current requesting url.
   * @param data The params for http get and body for http post.
   * @param baseUrl The string of http request base url.
   */
  private static async makeRequestWithTokenCheck(httpMethod: string, baseUrl: string, url: string, data: StringKeyObject = {}, requestAction: RequestAction = RequestAction.Normal): Promise<HttpResponseResult> {
    let isRefreshingTokenSuccessful = false;
    let response: AxiosResponse | null;

    if (await this.needToRefreshToken(url) &&
      TokenService.hasAccessToken &&
      TokenService.hasRefreshToken &&
      TokenService.isAccessTokenExpired) {
      if (!this.refreshingTokenPromise) {
        this.refreshingTokenPromise = new Promise((async (reslove) => {
          try {
            // Tries to refresh the token.
            isRefreshingTokenSuccessful = await this.refreshToken();

            return reslove();
          } finally {
            this.refreshingTokenPromise = null;
          }
        }));

        await this.refreshingTokenPromise;

        if (isRefreshingTokenSuccessful) {
          // If refreshing token process is done then we nee to retry the original request.
          return this.makeRequestWithTokenCheck(httpMethod, baseUrl, url, data, requestAction);
        } else {
          return this.checkHttpResponse(response!);
        }
      } else {
        // console.info('Waiting for refreshing token ...');

        await this.refreshingTokenPromise;

        // Contiunes the original request.
        return this.makeRequestWithTokenCheck(httpMethod, baseUrl, url, data, requestAction);
      }
    } else {
      response = await this.makeRequest(httpMethod, baseUrl, url, data, requestAction);

      return this.checkHttpResponse(response);
    }
  }

  /**
   * Refreshes the token if it can. It returns [true] if the refreshing is successful.
   */
  private static async refreshToken(): Promise<boolean> {
    // The uri for refreshing token.
    const url = await this.combineUrlWithDefault('/authentication/refreshToken');

    // TODO: Applies real deviceInfo & systemInfo as needed.
    // Prepares the post body.
    const body = {
      refreshToken: TokenService.activeRefreshToken,
      deviceInfo: {},
      systemInfo: {},
    }

    // If the refreshing token process is successful.
    let isRefreshingTokenSuccessful = false;

    let response: AxiosResponse;

    try {
      const config = this.getAxiosRequestConfig();

      // Makes http post to refresh the token.
      response = await axios.post(url, body, config);
      const result = this.checkHttpResponse(response);

      if (result.success) {
        // Gets the server data to user instance.
        const user = User.fromJSON(response.data.payload);

        // Saves the user data retrieved from server.
        AuthenticationService.saveUserInfo(user);

        const token = await Token.fromServerJson(response.data.payload);

        // Sets the new token retrieved from remote server.
        TokenService.saveToken(token);

        // Just in case user info has been changed since last login.
        useAuthenticationStore().changeCurrentUser(AuthenticationService.currentUser);

        isRefreshingTokenSuccessful = true;

        // console.info('Token refreshed');
      }
    } catch (e) {
      // We don't need to handle exception here. The original request will do. Such like network connection issue.
      // We just make sure this function returns without being interrupted by exception.
    }

    return isRefreshingTokenSuccessful;
  }

  /**
   * Checks on [response] to see if it is successful or not.
   *  
   * If it is successful then we will convert it to object.
   * If the status code is [HttpStatus.unauthorized] we will try to refresh the access token if we can.
   */
  private static checkHttpResponse(response: AxiosResponse | null): HttpResponseResult {
    let httpResponseResult: HttpResponseResult;

    // Checks if the response is successful or not.
    switch (response?.status) {
      case HttpStatus.ok:
      case HttpStatus.noContent:
        httpResponseResult = response.data;
        break;
      case HttpStatus.unauthorized:
        this.handleUnauthorizedError(response);
        break;
      case HttpStatus.forbidden:
        this.showMessage('抱歉，您被禁止该访问。');
        break;
      case HttpStatus.notFound:
        this.showMessage('当前服务不可访问');
        break;
      default:
        this.handleGeneralError(response!);
        break;
    }

    httpResponseResult ??= new HttpResponseResult(false);

    return httpResponseResult;
  }

  /**
   * By now the spring security rest returns [HttpStatus.unauthorized] for different reasons.
   * We need to check into the header to see what exactly happened.
   * Handles the error of the [HttpStatus.unauthorized].
   */
  private static handleUnauthorizedError(response: AxiosResponse): void {
    const errorDetails = this.getBodyErrorDetails(response) ?? this.getHeaderErrorDetails(response);

    if (!CommonService.isNullOrUndefinedOrEmptyString(errorDetails?.message)) {
      // Displays message to user.
      // Actually we did not display message like 'user not found' or 'password is incorrect'.
      // This is for void brute force attack. If the attacker does NOT know the login id or password is correct that would make the attach more difficult.
      this.showMessage(errorDetails!.message!);
    } else {
      this.showMessage('登录已过期或没有访问权限，请重新登录。');
    }

    this.logUserOut();
  }

  /**
   * By now the spring security rest returns [HttpStatus.unauthorized] for different reasons.
   * We need to check into the header to see what exactly happened.
   * Handles the error of the [HttpStatus.unauthorized].
   */
  private static handleGeneralError(response: AxiosResponse): void {
    const errorDetails = this.getBodyErrorDetails(response) ?? this.getHeaderErrorDetails(response);
    let errorMessage = '服务处理发生问题，可能网络或服务不可用，或服务出现未知情况。';

    if (!CommonService.isNullOrUndefinedOrEmptyString(errorDetails?.message)) {
      errorMessage += `: ${errorDetails?.message}。`;
    }

    this.showMessage(errorMessage);
  }

  /**
   * Gets the response error from body.
   */
  private static getBodyErrorDetails(response?: AxiosResponse): ErrorDetails | null {
    return this.getErrorDetails('message', response?.data);
  }

  /**
   * Gets the response error from header.
   */
  private static getHeaderErrorDetails(response?: AxiosResponse): ErrorDetails | null {
    return this.getErrorDetails('Error-Code', response?.headers);
  }

  /**
   * Gets the error message from [data] which might be the response body or header.
   * The [key] which used to find in the map to see if it contains error code.
   */
  private static getErrorDetails(key: string, data?: StringKeyObject | null): ErrorDetails | null {
    if (data) {
      let errorMessage: string | null;
      let errorCode: string | null;

      // Tries to get the error key from http header if the header key contains [key].
      // Uses orElse parameter to slient 'No Element' error if no match found.
      const errorKey = Object.keys(data).find((p) => p.toLowerCase().toLowerCase().includes(key));

      // If error code exists.
      if (!CommonService.isNullOrUndefinedOrEmptyString(errorKey)) {
        // Gets the code by the key.
        errorCode = data[errorKey!];

        // Gets the message by code.
        errorMessage = HttpResponseCodeManager.getCodeMessage(errorCode!);

        return {
          code: errorCode,
          message: errorMessage,
        };
      }
    }

    return null;
  }

  /// Shows message to user if anything goes wrong.
  private static showMessage(msg: string): void {
    // Avoids duplicate http error at the same time.
    if (document.getElementsByClassName('el-message').length === 0) {
      MessageService.showError(msg);
    }
  }

  private static logUserOut(): void { }
}